Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Referencer, Name conflicts, Mapped unions, etc. #1498

Merged
merged 12 commits into from
Nov 10, 2023

Conversation

nemnandor
Copy link

@nemnandor nemnandor commented Oct 19, 2023

All Submissions:

  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?
  • Have you written unit tests?
  • Have you written unit tests that cover the negative cases (i.e.: if bad data is submitted, does the library respond properly)?
  • This PR is associated with an existing issue?

Closing issues

closes #477
closes #623
closes #681
closes #1191
closes #1204
closes #1266
closes #1447
closes #1472

If this is a new feature submission:

  • Has the issue had a maintainer respond to the issue and clarify that the feature is something that aligns with the goals and philosophy of the project?

Fixes:

I have solved locally some problems which are needed for my company. After that I read the TSOA commit guides, so some fixes maybe have no related issues.

  1. Namespaces: Every type should be stored with their full-path namespaced types. Even if the source code not uses this name. This reduces conflicts between types.
  2. Name conflicts: If every reference type name parts are uniq, then the full type name can be handled as uniq. In this type name, we need to sign the jsdoc params if exists, because its they can make differences in the result. Somehow the reference type names are transformed with an irreversible getRefTypeName(), here we need to check if the result name comes from the same original type names, or from more different names.
  3. Handling interfaces - namespaces - enums which have more than one declaration
  4. Keep jsdoc comments + defaults after mapping - as intellisense shows
  5. Do not use original definition in mapped types, because type can change.
  6. Accept unions, intersections, nulls, enums in mapped type.
  7. Fix referencer issues. TypeToTypeNode & TypeNodeToType functions are not exactly the inverse function of each other -> use the original types, and remove conversions.
  8. Do not name more complicated reference type internal parts as "any". - It is needed to recognize name conflicts.
  9. Better keyof handling
  10. Do not mark required if a field has a default value. (Not too relevant, but more beautiful)
  11. Default values from JSdoc uses the expected type. @default true is not a string, but a boolean.

Potential Problems With The Approach

  • My code sometimes returns with an equivalent type definition, not the original (If Extended interface doesn't produce an allOf type #1490 is a bug, then I made bugs). Check original test changes.
  • I have not implemented Partial or Partial-like mapped types. I can implement it, but not really know if it is interesting or not/where is the end of this strange types.
  • I solved name conflict handling, but for it I generate the type name every time (previously tsoa made it in the else branch, but normally we used the typescript name). For some name, some type formations are not implemented, like:
    type A = Partial<{ [a in keyof B]: string }>
    But there is a workaround:
    type AInternal = { [a in keyof B]: string }; type A = Partial<AInternal>
  • A mapped type can change the type of a member, but can not change the jsdoc of it (sometimes deletes it, but sometimes not). So we can create a number member (with default value 2, and minimum 0), and after that, we can construct a mapped type which can change the type to string. Everything is fine with intellisense, but we can define a type which should have a value, because if not, the default value will have another type, so typecheck fails. Now TSOA handles type restrictions which are related only to one type, so in this case there will be not relevant type checks, not really interesting.
  • If a type (interface) has multiple declaration, and both declarations contains the same member with different jsdoc, the program chooses one of them.
  • Union types in typescript are a little bit strange, A | B ~ { some properties of A & some properties of B}. TSOA validation not handles them correctly (TSOA validation implementation: A | B = Only A | Only B). In a type with multiple different additionalProperties type I use type unions to "approxximate" the expected operation. It is ok until someone fixes TSOA validation. But I'm not sure, that most programmers love or hate this TSOA validation bug, I enjoy it, it is more strict than ts.

I'm sure, that there are a lot of breaking changes.

Test plan

Added test type literals to the TestModel interface. Every literal checks one solved problem. The members represents one test case.

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello there nemnandor 👋

Thank you and congrats 🎉 for opening your first PR on this project.✨

We will review the following PR soon! 👀

@@ -1119,7 +1137,7 @@ describe('Definition generation', () => {
{
properties: {
list: {
items: { $ref: '#/definitions/ThingContainerWithTitle_string_' },
items: { type: 'number', format: 'double' },
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Equivalent change, but maybe the previous solution was better readable

@@ -1169,13 +1188,13 @@ describe('Definition generation', () => {
format: undefined,
},
indexedResponseObject: {
$ref: '#/definitions/Record_id.any_',
$ref: '#/definitions/Record_id._myProp1-string__',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous name generation worked well only if we were not in a nested mapped type. Fixed to check&handle name conflicts

description: undefined,
example: undefined,
format: undefined,
},
indexedType: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },
indexedTypeToAlias: { $ref: '#/definitions/IndexedInterfaceAlias', description: undefined, format: undefined, example: undefined },
indexedTypeToAlias: { $ref: '#/definitions/IndexedInterface', description: undefined, format: undefined, example: undefined },
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Equivalent change, but maybe previous was better readable in some cases

@@ -131,7 +131,7 @@ describe('Definition generation', () => {
'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2',
'TestSubModel2',
'TestSubModelNamespace.TestSubModelNS',
'TsoaTest.TestModel73',
'tsoaTest.TsoaTest.TestModel73',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For safe name conflict handling, every type needs to use its fully namespaced name. Not accepted to think, that TsoaTest type is equal to another interface in the tsoaTest namespace

@@ -564,6 +564,7 @@ describe('Definition generation', () => {
default: undefined,
},
},
required: ['record-foo', 'record-bar'],
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong previous tests

@@ -834,7 +852,7 @@ describe('Definition generation', () => {
example: 42,
minimum: 42,
maximum: 42,
default: '42',
default: 42,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now default types are ok, so we can use default values with @default jsdoc comment

@@ -11,7 +11,7 @@ export class GenerateMetadataError extends Error {
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing, do not crash while trying to make exceptions to throw (or when make warning messages).

@@ -35,7 +36,6 @@ export class MetadataGenerator {

this.checkForMethodSignatureDuplicates(controllers);
this.checkForPathParamSignatureDuplicates(controllers);
this.circularDependencyResolvers.forEach(c => c(this.referenceTypeMap));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in another place

}
this.referenceTypeMap[decodeURIComponent(referenceType.refName)] = referenceType;
this.referenceTypeMap[referenceType.refName] = referenceType;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent() not interesting here, because typeResolver guarantees, that only OpenApi-accepted names can be generated.

}

public GetReferenceType(refName: string) {
return this.referenceTypeMap[refName];
}

public OnFinish(callback: (referenceTypes: Tsoa.ReferenceTypeMap) => void) {
this.circularDependencyResolvers.push(callback);
public CheckModelUnicity(refName: string, positions: Array<{ fileName: string; pos: number }>) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type unicity check:

  • We want to check, that type A<B<...>,{ c: C}> is uniq or not. For this, we need to check if every A, B & C type identifier definitions are uniq or not. Uniq = A type can have more than one definition, but their definition positions array must be uniq. Position = fileName + pos. Not stored, so not interesting, which local machine the check is made.
  • A and A_B_ types are both renamed to A_B_ to use it in OpenApi. This is a second type of name conflicts.

jsonCharacters: undefined; //type is not really interesting
};

jsDocTypeNames?: {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This types are different because of their jsdoc comments. Should generate different TSOA types for it (with, of course, different names).
Test checks situations which were handled previously as Partial_any_ to cover a more special situation

}>;
};

jsdocMap?: {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shows how to change jsdocs while changing the interface with mapped types

synonym2: JsDoccedSynonym2;
};

duplicatedDefinitions?: {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

identifiers with more than one definition

[name: string]: ts.TypeReferenceNode | ts.TypeNode;
[name: string]: {
type: ts.TypeNode;
name: string;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name is only for cache

description: this.getNodeDescription(propertySignature),
format: this.getNodeFormat(propertySignature),
name: (propertySignature.name as ts.Identifier).text,
required: !propertySignature.questionToken,
required: !propertySignature.questionToken && def === undefined,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a type has default value, then it can not be required.
If a string has a number default value (for example, because of a type mapping), then no problem, the not defined value will be replaced with the number, and after that the typeCheck says, it is not the expected type.

Copy link
Collaborator

@WoH WoH Nov 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this interact with: #1454

My approach was to leave that up to the mapping of internal types -> OpenAPI 2/3, while keeping the metadata rather unrefined and applying "intentions" later.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I removed the automatic non-required if there is a default value.

const propertyType = this.current.typeChecker.getTypeOfSymbol(property);

const typeNode = this.current.typeChecker.typeToTypeNode(propertyType, undefined, ts.NodeBuilderFlags.NoTruncation)!;
const parent = getOneOrigDeclaration(property); //If there are more declarations, we need to get one of them, from where we want to recognize jsDoc
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are more than one declaration for a type member, then choose one of them for handling jsdoc.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all other cases we throw if there are multiple. This seems like an intentional deviation?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, tsoa gets the [0]-th element. I think my code is not worse than the original.
Of course, I can make, that the parent member of TypeResolver is not a single value, but an array, and everywhere we want to use it, we check if every elements gets the same result or not. If you think it makes a better world.


const name = this.contextualizedName(resolvableName);
private calcMemberJsDocProperties(arg: ts.PropertySignature): string {
const def = TypeResolver.getDefault(arg);
Copy link
Author

@nemnandor nemnandor Oct 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If two types differ only in jsdoc, then their name needs to differ, too. Because of it, I need to inject the jsdoc parameters into the type name. The name syntax can not be a valid tsoa syntax, but it's not interesting

let textStartCharacter: `"` | "'" | '`' | undefined = undefined;
const inString = () => textStartCharacter !== undefined;

let formattedStr = '';
Copy link
Author

@nemnandor nemnandor Oct 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#623 JSON not easily supports escaped characters, and only strings with double quotation marks, so preformat is needed

return {
dataType: 'enum',
enums: [null],
};
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined & null types are interesting if strictNullChecks flag is true in ts compilerOptions

@WoH
Copy link
Collaborator

WoH commented Oct 23, 2023

Can you take a look at CI? Let's start there.

// keyof
if (ts.isTypeOperatorNode(this.typeNode) && this.typeNode.operator === ts.SyntaxKind.KeyOfKeyword) {
const type = this.current.typeChecker.getTypeFromTypeNode(this.typeNode);
if (type.getFlags() & ts.TypeFlags.Index) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a helper that makes this typesafe without casting?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good notice, I have fixed it.

// in case of generic: keyof T. Not handles all possible cases
const symbol = (type as ts.IndexType).type.getSymbol();
if (symbol && symbol.getFlags() & ts.TypeFlags.TypeParameter) {
const typeName = symbol.getEscapedName();
Copy link
Collaborator

@WoH WoH Nov 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add: assert(typeof typeName === 'string')

Validates and avoids casts

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@WoH
Copy link
Collaborator

WoH commented Nov 10, 2023

LGTM, thanks for the PR and sorry for the time it took to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment